;;; dropbox.el --- Dropbox Client -*- lexical-binding: t -*-

;; Copyright (C) 2022 lorniu <lorniu@gmail.com>

;; Author: lorniu <lorniu@gmail.com>
;; URL: https://github.com/lorniu/dropbox.el
;; Package-Requires: ((emacs "27.1") (pdd "0.2.2"))
;; Keywords: tools
;; SPDX-License-Identifier: MIT
;; Version: 2.0.1

;;; Commentary:

;; Dropbox Client for Emacs.
;;
;; 1. Download `dropbox.el' and load it. For example:
;;
;;    (use-package dropbox
;;      :vc (:url "https://github.com/lorniu/dropbox.el")
;;      :config
;;      (setq dropbox-config-file "~/.dropbox")
;;      (setq dropbox-http-backend (pdd-curl-backend)))
;;
;; 2. The first time, generate the config file with `M-x dropbox-gen-config`.
;;
;; 3. Then use `C-x C-f /db:' or `C-x C-f /db:/PATH-ON-DROPBOX' to open file,
;;    and use command `dropbox-find' to search and pick file.
;;
;; Visit https://github.com/lorniu/dropbox.el for more information

;;; Code:

;; https://www.dropbox.com/developers/documentation/http/documentation

(require 'cl-lib)
(require 'json)
(require 'dired-aux)
(require 'pdd)

(defgroup dropbox nil
  "Dropbox on Emacs."
  :prefix "dropbox-"
  :group 'applications)

(defcustom dropbox-debug nil
  "Toggle debug output."
  :type 'boolean)

(defcustom dropbox-config-file "~/.dropbox"
  "Use the config file generated by dropbox.shell."
  :type 'string)

(defcustom dropbox-http-backend pdd-backend
  "Http backend used. See `pdd-backend' for details."
  :type 'sexp)

;;;###autoload
(defconst dropbox-prefix "/db:")

(defvar dropbox--config nil)

(defvar dropbox--shared-storage (make-hash-table :test #'equal))

(defvar-local dropbox--files nil)

(defvar recentf-list)

(defun dropbox-log (fmt-string &rest args)
  (when dropbox-debug
    (apply #'message (concat "\t[Dropbox] " fmt-string) args)))

(defun dropbox-normalize (path &optional force-prefix-p)
  (when path
    (when (string-match-p "/$" path)
      (setq path (substring path 0 -1)))
    (when (string-match-p (concat "^" dropbox-prefix) path)
      (setq path (substring path (length dropbox-prefix)))))
  (concat (if force-prefix-p dropbox-prefix) path))

(defun dropbox-file-p (filename)
  (string-prefix-p dropbox-prefix filename))

(defun dropbox-file-at-point (&optional allow-dir)
  "Return Dropbox path at point.
When ALLOW-DIR is nil, only return the file."
  (let ((path (if (derived-mode-p 'dired-mode)
                  (dired-get-filename)
                buffer-file-name)))
    (unless path
      (user-error "No Dropbox file found at point"))
    (unless (dropbox-file-p path)
      (user-error "Not a Dropbox file: %s" path))
    (when (and (null allow-dir) (file-directory-p path))
      (user-error "Directory is not allowed: %s" path))
    path))

(defun dropbox-temp-file-buffer (filename content &optional pop rw post)
  (let* ((prefix (concat (file-name-sans-extension (file-name-nondirectory filename)) "-"))
         (extension (file-name-extension filename))
         (recentf-list nil)
         (inhibit-message t)
         (message-log-max nil)
         (tempfile (make-temp-file prefix nil (if extension (concat "." extension)) content)))
    (with-current-buffer (find-file-noselect tempfile)
      (unless rw (view-mode))
      (if pop (pop-to-buffer (current-buffer) (if (consp pop) pop)))
      (add-hook 'kill-buffer-hook (lambda () (ignore-errors (delete-file buffer-file-name))) nil t)
      (if post (funcall post))
      (current-buffer))))

(defun dropbox-content-hash (file)
  "Calculate the Dropbox content_hash for a local FILE."
  (unless (file-exists-p file) (error "File not found: %s" file))
  (with-temp-buffer
    (set-buffer-multibyte nil)
    (insert-file-contents-literally file)
    (let ((chunk-size (* 4 1024 1024))
          (file-size (buffer-size)))
      (if (zerop file-size)
          "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        (let ((block-hashes '()) (current-pos 1))
          (while (<= current-pos file-size)
            (let* ((end-pos (min (+ current-pos chunk-size) (1+ file-size)))
                   (chunk-string (buffer-substring-no-properties current-pos end-pos))
                   (chunk-hash-binary (secure-hash 'sha256 chunk-string nil nil 'binary)))
              (push chunk-hash-binary block-hashes)
              (setq current-pos end-pos)))
          (let ((all-hashes-concatenated-binary (apply #'concat (reverse block-hashes))))
            (mapconcat (lambda (byte) (format "%02x" byte))
                       (secure-hash 'sha256 all-hashes-concatenated-binary nil nil 'binary))))))))


;;; API

(defun dropbox-request (url &rest args)
  (declare (indent 1))
  (pdd-then (unless (string-match-p "/oauth2" url)
              (dropbox-token)) ; skip auth for token request
    (lambda (token)
      (let ((pdd-sync (eq pdd-sync t)) ; make unset means asyc
            (pdd-headers (if token `((bear ,token))))
            (pdd-active-cacher (pdd-cacher :ttl 3 :store 'dropbox--shared-storage))
            (pdd-backend dropbox-http-backend))
        (if-let* ((msg (plist-get args :progress))) ; use :progress to show progress
            (let* ((reporter (make-progress-reporter msg))
                   (pdd-peek (lambda (headers)
                               (let* ((total (string-to-number (alist-get 'content-length headers)))
                                      (percent (format "%.1f%%" (/ (* 100.0 (buffer-size)) total))))
                                 (progress-reporter-update reporter percent)))))
              (apply #'pdd url args))
          (apply #'pdd url args))))))

(defun dropbox--refresh-token (access-code app-key app-secret)
  (dropbox-request "https://api.dropbox.com/oauth2/token"
    :data `((grant_type . authorization_code)
            (code . ,access-code)
            (client_id . ,app-key)
            (client_secret . ,app-secret))
    :done (lambda (result)
            (alist-get 'refresh_token result))))

(defun dropbox--access-token (refresh-token app-key app-secret)
  (dropbox-request "https://api.dropbox.com/oauth2/token"
    :data `((grant_type . refresh_token)
            (refresh_token . ,refresh-token)
            (client_id . ,app-key)
            (client_secret . ,app-secret))
    :done (lambda (result)
            (cons (alist-get 'access_token result)
                  (time-add (current-time) (seconds-to-time (- (alist-get 'expires_in result) 120)))))))

(defun dropbox--get-current-account ()
  (dropbox-request "https://api.dropboxapi.com/2/users/get_current_account"
    :cache '(10000 account)
    :headers '(t json)
    :data "null"))

(defun dropbox--get-space-usage ()
  (dropbox-request "https://api.dropboxapi.com/2/users/get_space_usage"
    :cache '(60 space-usage)
    :headers '(t json)
    :data "null"))

(defun dropbox--list-folder (path)
  (dropbox-request "https://api.dropboxapi.com/2/files/list_folder"
    :cache '(5 folder (data . path))
    :headers '(t json)
    :data `((path . ,(dropbox-normalize path))
            (recursive . :json-false)
            (include_media_info . :json-false)
            (include_deleted . :json-false)
            (include_has_explicit_shared_members . :json-false)
            (include_mounted_folders . t))
    :done (lambda (result)
            (cl-labels ; if has more results, fetch all of them
                ((fetch-next (cursor lst)
                   (if cursor
                       (dropbox-request "https://api.dropboxapi.com/2/files/list_folder/continue"
                         :headers '(t json) :data `((cursor . ,cursor))
                         :done (lambda (result)
                                 (fetch-next (when (eq (alist-get 'has_more result) t) (alist-get 'cursor result))
                                             (append lst (cl-coerce (alist-get 'entries result) 'list)))))
                     lst)))
              (fetch-next (when (eq (alist-get 'has_more result) t) (alist-get 'cursor result))
                          (cl-coerce (alist-get 'entries result) 'list))))))

(defun dropbox--list-revisions (path &optional limit)
  (dropbox-request "https://api.dropboxapi.com/2/files/list_revisions"
    :cache '(5 revisions (data . path) (data . limit))
    :headers '(t json)
    :data `((path . ,(dropbox-normalize path))
            (mode . path)
            (limit . ,(or limit 100)))
    :done (lambda (result)
            (when (eq (alist-get 'is_deleted result) :false)
              (cl-coerce (alist-get 'entries result) 'list)))))

(defun dropbox--search (text &optional path)
  (dropbox-request "https://api.dropboxapi.com/2/files/search_v2"
    :cache '(10 search (data . query) (data . (options . path)))
    :headers '(t json)
    :data `((query . ,text)
            (options (file_status . active)
                     (filename_only . :json-false)
                     (max_results . 1000)
                     (path . ,(or (dropbox-normalize path) "")))
            (match_field_options (include_highlights . :json-false)))))

(defun dropbox--get-metadata (path &optional sync)
  (let ((pdd-sync sync)
        (path (dropbox-normalize path)))
    (if (string= path "")
        (pdd-then (dropbox-config)
          (lambda (_)
            '((name . "")
              (\.tag . "folder")
              (path_lower . "")
              (path_display . ""))))
      (dropbox-request "https://api.dropboxapi.com/2/files/get_metadata"
        :cache '(60 meta (data . path))
        :headers '(t json)
        :data `(("path" . ,path)
                ("include_media_info" . :json-false)
                ("include_deleted" . :json-false)
                ("include_has_explicit_shared_members" . :json-false))
        :fail #'ignore))))

(defun dropbox--create-folder (path)
  (dropbox-request "https://api.dropboxapi.com/2/files/create_folder_v2"
    :headers '(t json)
    :data `(("path" . ,(dropbox-normalize path))
            ("autorename" . :json-false))))

(defun dropbox--delete (file &optional permanently)
  (dropbox-request (concat "https://api.dropboxapi.com/2/files/"
                           (if permanently "permanently_delete" "delete_v2"))
    :headers '(t json)
    :data `(("path" . ,(dropbox-normalize file)))))

(defun dropbox--move (from to)
  (dropbox-request "https://api.dropboxapi.com/2/files/move_v2"
    :headers '(t json)
    :data `(("from_path" . ,(dropbox-normalize from))
            ("to_path" . ,(dropbox-normalize to)))))

(defun dropbox--copy (from to)
  (dropbox-request "https://api.dropboxapi.com/2/files/copy_v2"
    :headers '(t json)
    :data `(("from_path" . ,(dropbox-normalize from))
            ("to_path" . ,(dropbox-normalize to)))))

(defun dropbox--upload (from to &optional overwrite)
  (dropbox-request "https://content.dropboxapi.com/2/files/upload"
    :headers `(t ct-bin)
    :params `((arg . ,(encode-coding-string
                       (json-encode
                        `(("path" . ,(dropbox-normalize to))
                          ("mode" . ,(if overwrite "overwrite" "add"))
                          ("autorename" . t)
                          ("mute" . :json-false)
                          ("strict_conflict" . :json-false)))
                       'utf-8)))
    :data (with-temp-buffer
            (insert-file-contents-literally from)
            (set-buffer-multibyte nil)
            (buffer-string))))

(defcustom dropbox-disk-cache-location
  (let ((dir (locate-user-emacs-file "dropbox-caches")))
    (make-directory dir t)
    dir)
  "Location of disk caches."
  :type 'directory)

(defcustom dropbox-disk-cache-url-regexp "\\.\\(epub\\|pdf\\)$"
  "Regexp for file url determine which should be cached into disk."
  :type 'string)

(defun dropbox--download (target &optional zip? cache-disk?)
  "Download TARGET which is path, id or revision.
When target is not path, there will be a prefix delemite with colon.
If ZIP? is non-nil, download as a zip (for directory).
If CACHE-DISK? is non-nil, force cache to disk."
  (unless (or (string-prefix-p "id:" target) (string-prefix-p "rev:" target))
    (setq target (dropbox-normalize target)))
  (when (and zip? (not (file-directory-p (dropbox-normalize target t))))
    (user-error "Download to .zip is used for directory"))
  (dropbox-request (concat "https://content.dropboxapi.com/2/files/download" (if zip? "_zip"))
    :cache (if (string-prefix-p "rev:" target)
               ;; cache revisions to memory
               '(1800 revision (params . arg))
             (when-let* ((meta (dropbox--get-metadata target t))
                         (key (substring (alist-get 'id meta) 3)) ; windows not support : in path
                         (cfile (concat (expand-file-name key dropbox-disk-cache-location) "@000.cache")))
               ;; cache some files to local disk
               (when (or (file-exists-p cfile) cache-disk? (string-match-p dropbox-disk-cache-url-regexp target))
                 (list (lambda () ; cache alive when content_hash is same
                         (and (file-exists-p cfile)
                              (equal (dropbox-content-hash cfile) (alist-get 'content_hash meta))
                              (message "Found in cache (clear with `dropbox-clear-cache-on-disk').")))
                       key (cons 'store dropbox-disk-cache-location)))))
    :params `((arg . ,(encode-coding-string (json-encode `(("path" . ,target))) 'utf-8)))
    :done (lambda (r) (message "") r) ; response type: application/octet-stream
    :progress "Fetching"))

(defun dropbox--restore (revision path)
  (dropbox-request "https://api.dropboxapi.com/2/files/restore"
    :headers '(t json)
    :data `((rev . ,revision) (path . ,(dropbox-normalize path)))))

(defun dropbox--create-shared-link (path &optional settings)
  (dropbox-request "https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings"
    :headers '(t json)
    :data `((path . ,(dropbox-normalize path))
            (settings . ,(or settings
                             `(("access" . "viewer")
                               ("allow_download" . t)
                               ("audience" . "public")
                               ("requested_visibility" . "public")))))
    :done (lambda (r) (alist-get 'url r))))

(defun dropbox--list-shared-links (&optional path)
  (dropbox-request "https://api.dropboxapi.com/2/sharing/list_shared_links"
    :cache `(2 shared ,(if path '(data . path)))
    :headers '(t json)
    :data (if path `((path . ,(dropbox-normalize path))) "null")
    :done (lambda (r) (cl-coerce (alist-get 'links r) 'list))))

(defun dropbox--revoke-shared-link (url)
  (dropbox-request "https://api.dropboxapi.com/2/sharing/revoke_shared_link"
    :headers '(t json)
    :data `((url . ,url))))

(defun dropbox--get-temporary-link (path)
  (dropbox-request "https://api.dropboxapi.com/2/files/get_temporary_link"
    :headers '(t json)
    :data `((path . ,(dropbox-normalize path)))
    :done (lambda (r) (alist-get 'link r))))

;; (dropbox-token)
;; (dropbox--get-current-account)
;; (dropbox--get-space-usage)
;; (dropbox--list-folder "/vvv")
;; (dropbox--search "p")
;; (dropbox--get-metadata "/vvv")
;; (dropbox--get-metadata "/")
;; (dropbox--get-metadata "/vvv")
;; (dropbox--create-folder "/vvv/c2cc")
;; (dropbox--delete "/vvv/c2cc")
;; (dropbox--move "/vvv/bashrc.txt" "/vvv/bashrc-2.txt")
;; (dropbox--copy "/vvv/bashrc-2.txt" "/vvv/bashrc-4.txt")
;; (dropbox--upload "/home/vip/.bashrc" "/vvv/bashrc.txt")
;; (dropbox--download "/vvv/persons.json")
;; (dropbox--download "/vvv" nil)
;; (maphash (lambda (k v) (message "> %S" k)) dropbox--shared-storage)


;;; Authorization

;;;###autoload
(defun dropbox-gen-config ()
  "Generate the config file containing auth infomation."
  (interactive)
  (require 'widget)
  (if (file-exists-p dropbox-config-file)
      (user-error "Config file '%s' exists, delete it or give up" dropbox-config-file)
    (setq dropbox--config nil)
    (with-current-buffer (get-buffer-create "*Dropbox Config*")
      (let ((apps-url "https://www.dropbox.com/developers/apps")
            (auth-url "https://www.dropbox.com/oauth2/authorize?client_id=%s&token_access_type=offline&response_type=code")
            wkey wsec wcode)
        ;; init
        (kill-all-local-variables)
        (let ((inhibit-read-only t)) (erase-buffer))
        (remove-overlays)
        ;; widgets
        (widget-insert "Generate token for Dropbox (step by step)\n\n1. Click ")
        (widget-create 'link :value apps-url :notify (lambda (&rest _) (browse-url apps-url)))
        (widget-insert " to create/config a Dropbox App:

   - Permissions: Grant 'files.metadata.read/write', 'files.content.read/write' and more.
   - Settings: Record your 'App key' and 'App secret'.

2. Input 'App key' & 'App secret' that get from step 1\n\n")
        (setq wkey (widget-create 'editable-field :size 16 :format "   App key:    %v\n\n"))
        (setq wsec (widget-create 'editable-field :size 16 :format "   App secret: %v\n\n"))
        (widget-insert "3. Click ")
        (widget-create 'push-button
                       :notify (lambda (&rest _)
                                 (let ((app-key (string-trim (widget-value wkey)))
                                       (app-secret (string-trim (widget-value wsec))))
                                   (if (or (zerop (length app-key)) (zerop (length app-secret)))
                                       (message "Please input `app key' and `app secret' first")
                                     (browse-url (format auth-url app-key)))))
                       "Get Access Code")
        (widget-insert ", authorize in browser, and paste code here.\n\n")
        (setq wcode (widget-create 'editable-field :size 50 :format "   Access code: %v\n\n\n"))
        (widget-insert "If all ready, ")
        (widget-create 'push-button
                       :notify (lambda (&rest _)
                                 (let ((pdd-sync t)
                                       (app-key (string-trim (widget-value wkey)))
                                       (app-secret (string-trim (widget-value wsec)))
                                       (access-code (string-trim (widget-value wcode))))
                                   (if (cl-some (lambda (x) (zerop (length x))) (list app-key app-secret access-code))
                                       (message "Please make sure previous steps are ready")
                                     (pdd-then (dropbox--refresh-token access-code app-key app-secret)
                                       (lambda (refresh-token)
                                         (with-temp-file dropbox-config-file
                                           (insert (format "CONFIGFILE_VERSION=2.0\nOAUTH_APP_KEY=%s\nOAUTH_APP_SECRET=%s\nOAUTH_REFRESH_TOKEN=%s"
                                                           app-key app-secret refresh-token)))
                                         (kill-buffer-and-window)
                                         (message "File `%s` generated done!" dropbox-config-file))))))
                       "click here to Generate the config file")
        (widget-insert ", or kill this buffer to give up.\n")
        (set-buffer-modified-p nil)
        ;; apply
        (use-local-map widget-keymap)
        (widget-setup)
        ;; display
        (goto-char (point-min))
        (pop-to-buffer (current-buffer) '((display-buffer-at-bottom)))))))

(defun dropbox-config ()
  (if (file-exists-p dropbox-config-file)
      (or dropbox--config
          (with-temp-buffer
            (insert-file-contents dropbox-config-file)
            (cl-loop for line in (split-string (buffer-string) "\n")
                     if (string-match-p "=" line)
                     collect (let* ((linepair (split-string line "="))
                                    (key (intern (concat ":" (replace-regexp-in-string "oauth_" "" (downcase (car linepair))))))
                                    (value (cadr linepair)))
                               (cons key value ))
                     into fs finally return
                     (if (alist-get :refresh_token fs)
                         (setq dropbox--config fs)
                       (user-error "Load config file `%s' failed, please check and regenerate" dropbox-config-file)))))
    (user-error "No `%s' found, please execute `M-x dropbox-gen-config' and follow the guide to generate one" dropbox-config-file)))

(defun dropbox-token ()
  (let ((config (dropbox-config)))
    (if-let* ((expired (alist-get :expired config))
              (token (alist-get :access_token config))
              (avail (and token expired (time-less-p (current-time) expired))))
        (if (eq pdd-sync t) token (pdd-resolve token))
      (message "[dropbox] Getting token...")
      (pdd-then (dropbox--access-token (alist-get :refresh_token config)
                                       (alist-get :app_key config)
                                       (alist-get :app_secret config))
        (lambda (token-and-etime)
          (setf (alist-get :expired dropbox--config) (cdr token-and-etime))
          (setf (alist-get :access_token dropbox--config) (car token-and-etime)))))))


;;; Handler

;;;###autoload
(defun dropbox-handler (operation &rest args)
  (let ((handler (intern (format "dropbox-handle:%s" operation))))
    (if (fboundp handler)
        (apply handler args)
      (dropbox-run-real-handler operation args))))

(defun dropbox-run-real-handler (operation args)
  (let* ((inhibit-file-name-handlers `(dropbox-handler
                                       tramp-file-name-handler
                                       tramp-vc-file-name-handler
                                       tramp-completion-file-name-handler
                                       . ,inhibit-file-name-handlers))
         (inhibit-file-name-operation operation))
    (apply operation args)))

(defun dropbox-handle:file-exists-p (filename)
  (dropbox-log "[handler] file-exists-p: %s" filename)
  (setq filename (dropbox-normalize filename))
  ;; patch for some special cases, for performance
  (cond ((string-match-p "^/?$" filename) t)
        ((string-match-p "~/" filename) nil)
        ((string-match-p "[/.]tags$" filename) nil) ; citre
        (t (ignore-errors (dropbox--get-metadata filename t)))))

(defun dropbox-handle:file-readable-p (filename)
  (cond ((string-match-p "\\.editorconfig" filename) nil) ; boring editorconfig! disable hardcode
        (t t)))

(defun dropbox-handle:file-directory-p (filename)
  (dropbox-log "[handler] file-directory-p: %s" filename)
  (let* ((filename (dropbox-normalize filename))
         (metadata (ignore-errors (dropbox--get-metadata filename t))))
    (and metadata (string= (alist-get '\.tag metadata) "folder"))))

(defun dropbox-handle:file-executable-p (filename)
  (dropbox-handle:file-directory-p filename))

(defun dropbox-handle:file-regular-p (file)
  (not (file-directory-p file)))

(defun dropbox-handle:file-remote-p (file &optional identification _connected)
  (cl-case identification
    ((method) dropbox-prefix)
    ((user) "")
    ((host) "")
    ((localname) (dropbox-normalize file))
    (t (concat dropbox-prefix "/"))))

(defun dropbox-handle:expand-file-name (name &optional dir)
  (if (string-prefix-p dropbox-prefix name) name
    (dropbox-run-real-handler #'expand-file-name (list name dir))))

(defun dropbox-handle:file-name-directory (filename)
  (if (string-match (concat "^\\(" dropbox-prefix ".*/\\).*$") filename)
      (match-string 1 filename)
    dropbox-prefix))

(defun dropbox-handle:file-name-nondirectory (filename)
  (if (string-match (concat "^" dropbox-prefix ".*/\\(.*\\)$") filename)
      (match-string 1 filename)
    (substring filename 4)))

;; CRUD

(defun dropbox-handle:make-directory (dir &optional _parents)
  (setq dir (dropbox-normalize dir))
  (pdd-then (dropbox--create-folder dir)
    (lambda (_) (message "Folder %s created!" dir))))

(defun dropbox-handle:delete-file (filename &optional _trash)
  (setq filename (dropbox-normalize filename))
  (pdd-then (dropbox--delete filename)
    (lambda (_) (message "Delete %s done!" filename))))

(defun dropbox-handle:delete-directory (directory &optional _recursive _trash)
  (setq directory (dropbox-normalize directory))
  (dropbox-clear-cache (lambda (key)
                         (or (string-prefix-p directory (cdr-safe (car-safe key)))
                             (eq (car-safe key) 'folder))))
  (dropbox-handle:delete-file directory))

(defun dropbox-handle:copy-file (file newname &optional _ok-if-already-exists _keep-time _preserve-uid-gid _preserve-selinux-context)
  (cond ((and (dropbox-file-p file) (dropbox-file-p newname))
         (setq file (dropbox-normalize file)
               newname (dropbox-normalize newname))
         (dropbox-clear-cache (lambda (key)
                                (or (string-prefix-p
                                     (directory-file-name (file-name-directory file))
                                     (cdr-safe (car-safe key)))
                                    (eq (car-safe key) 'folder))))
         (let ((pdd-sync t)) (dropbox--copy file newname)))
        ((and (dropbox-file-p file) (not (dropbox-file-p newname)))
         (rename-file (file-local-copy file) newname))
        ((and (not (dropbox-file-p file)) (dropbox-file-p newname))
         (pdd-then (dropbox--upload file newname)
           (lambda (_) (message "Upload done."))))))

(defun dropbox-handle:copy-directory (directory newname &optional keep-time parents copy-contents)
  (cond ((and (dropbox-file-p directory) (dropbox-file-p newname))
         (if parents (make-directory
                      (file-name-directory (directory-file-name newname))
                      parents))
         (copy-file directory newname nil keep-time parents copy-contents))
        ((and (dropbox-file-p directory) (string-match-p "\\.zip$" newname))
         (pdd-then (dropbox--download directory t)
           (lambda (content)
             (with-temp-file newname (insert content)))))
        ((dropbox-file-p directory)
         (user-error "Copy directory from remote to local is not supported, but you can copy to a local `*.zip' file then decompress"))
        (t
         (user-error "Copy directory from local to remote is not supported"))))

(defun dropbox-handle:rename-file (file newname &optional ok-if-already-exists)
  (cond ((and (dropbox-file-p file) (dropbox-file-p newname))
         (setq file (dropbox-normalize file)
               newname (dropbox-normalize newname))
         (dropbox-clear-cache (lambda (key)
                                (or (string-prefix-p
                                     (directory-file-name (file-name-directory file))
                                     (cdr-safe (car-safe key)))
                                    (eq (car-safe key) 'folder))))
         (let ((pdd-sync t)) (dropbox--move file newname)))
        ((and (dropbox-file-p file) (not (dropbox-file-p newname)))
         (copy-file file newname ok-if-already-exists)
         (delete-file file t))
        ((and (not (dropbox-file-p file)) (dropbox-file-p newname))
         (copy-file file newname ok-if-already-exists)
         (delete-file file t))))

;; Contents

(defun dropbox--set-progress (&optional index)
  (let* ((index (or index 0))
         (finished (or (null dropbox--files) (= (length dropbox--files) index))))
    (save-excursion
      (goto-char (point-min))
      (re-search-forward "), " nil t)
      (with-silent-modifications
        (delete-region (point) (line-end-position))
        (insert (if finished
                    (propertize (format "%s items" index)
                                'pointer 'arrow
                                'help-echo (format "%d" (length dropbox--files)))
                  (format "loading %s/%s" index (length dropbox--files))))
        (cond ((null dropbox--files)
               (message "Loading...done with nothing found."))
              (finished
               (message "Loading...done")))))))

(defun dropbox-handle:file-modes (&rest _) 492)

(defun dropbox-handle:file-attributes (filename &optional _ ometadata)
  (let* ((file (dropbox-normalize filename))
         (meta (or ometadata (dropbox--get-metadata file t)))
         (date (date-to-time (or (alist-get 'client_modified meta) "0000-01-01T00:00:00Z")))
         (folder (if meta (string= "folder" (alist-get '.tag meta)) (file-directory-p file)))
         (size (if meta (alist-get 'size meta)))
         (perm (concat (if folder "d" "-") "rwxr-xr--")))
    ;; folder? / links / UID / GID / atime / mtime / ctime / size / perm
    (list folder 1 0 0 date date date (or size 0) perm t nil nil)))

(defun dropbox-handle:insert-directory (filename switches &optional _wildcard full-directory-p)
  (dropbox-log "[handler] insert-directory: %s" filename)
  (let* ((filename (expand-file-name filename))
         (buffer (current-buffer))
         (create-row (lambda (entry)
                       (let* ((name (alist-get 'name entry))
                              (cache (when (equal (alist-get '.tag entry) "file")
                                       (file-exists-p (concat (expand-file-name (substring (alist-get 'id entry) 3) dropbox-disk-cache-location)
                                                              "@000.cache"))))
                              (attrs (dropbox-handle:file-attributes name nil entry))
                              (prefix (format "%s %s %2d %2s %2s %8s %s "
                                              (if cache "↓" " ")
                                              (elt attrs 8) (elt attrs 1) (elt attrs 2) (elt attrs 3)
                                              (file-size-human-readable (elt attrs 7))
                                              (format-time-string "%Y-%m-%d %H:%M" (elt attrs 4))))
                              (suffix (with-temp-buffer
                                        (insert name "\n")
                                        (put-text-property (point-min) (- (point-max) 1) 'dired-filename t)
                                        (put-text-property (point-min) (- (point-max) 1) 'face 'underline)
                                        (buffer-string))))
                         (with-current-buffer buffer
                           (goto-char (point-max))
                           (with-silent-modifications
                             (insert prefix)
                             (save-excursion (insert suffix))))))))
    (if (not full-directory-p)
        (pdd-then (dropbox--get-metadata filename) create-row)
      (pdd-async
        (let* ((reporter (make-progress-reporter "Loading"))
               (usage (await (dropbox--get-space-usage)))
               (used (alist-get 'used usage))
               (allocated (alist-get 'allocated (alist-get 'allocation usage))))
          (with-current-buffer buffer
            (with-silent-modifications
              (goto-char (point-max))
              (insert (format "  dropbox: %s (total %s, used %.0f%%), loading...\n"
                              (file-size-human-readable used)
                              (file-size-human-readable allocated)
                              (/ (* used 100.0) allocated))))))
        (if (setq dropbox--files (await (dropbox--list-folder filename)))
            (cl-loop with filter-and-sort =
                     (lambda (type)
                       (cl-sort (cl-remove-if-not
                                 (lambda (file) (equal (alist-get '.tag file) type)) dropbox--files)
                                (lambda (x y)
                                  (if (ignore-errors (string-match-p "-t" switches))
                                      (string> (alist-get 'client_modified x) (alist-get 'client_modified y))
                                    (string< (alist-get 'name x) (alist-get 'name y))))))
                     for entry in (append (funcall filter-and-sort "folder") (funcall filter-and-sort "file"))
                     for index from 1
                     do (save-excursion
                          (funcall create-row entry)
                          (progress-reporter-update reporter (alist-get 'name entry))
                          (dropbox--set-progress index)))
          (dropbox--set-progress))))))

(defun dropbox-handle:insert-file-contents (filename &optional visit beg end replace)
  (dropbox-log "[handler] insert-file-contents: %s" filename)
  (let* ((recentf-list nil)
         (tmpfile (make-temp-file (file-name-nondirectory filename))))
    (unwind-protect
        (let ((pdd-sync t) count)
          (when (file-exists-p filename)
            (with-temp-buffer
              (set-buffer-multibyte nil)
              (insert (dropbox--download filename nil current-prefix-arg))
              (let ((coding-system-for-write 'no-conversion))
                (write-region nil nil tmpfile nil 'no-message)))
            (if replace (erase-buffer))
            (insert-file-contents tmpfile visit beg end)
            (setq count (buffer-size)))
          (when visit
            (setf buffer-file-name filename)
            (setf buffer-read-only (not (file-writable-p filename)))
            (set-visited-file-modtime (current-time))) ; assume that no concurrent edit
          (cons filename count))
      (ignore-errors (delete-file tmpfile)))))

(defun dropbox-handle:write-region (beg end filename &optional append visit _lockname _mustbenew)
  (dropbox-log "[handler] write-region: %s, %s, %s" filename beg end)
  (cl-assert (not append))
  (let* ((recentf-list nil)
         (buffer (current-buffer))
         (tmpfile (make-temp-file (file-name-nondirectory filename)))
         (coding-system-for-write buffer-file-coding-system))
    (condition-case err
        (let ((inhibit-modification-hooks t)
              (coding-system-for-write 'no-conversion))
          (write-region beg end tmpfile nil 'no-message))
      (error (ignore-errors (delete-file tmpfile))
             (user-error "Write error: %s" err)))
    (pdd-then (dropbox--upload tmpfile filename t)
      (lambda (_)
        (ignore-errors (delete-file tmpfile))
        (with-current-buffer buffer
          (setq-local create-lockfiles nil)
          (when (stringp visit)
            (set-visited-file-name visit))
          (when (or (eq t visit) (stringp visit))
            (set-buffer-modified-p nil))
          (when (or (eq t visit) (eq nil visit) (stringp visit))
            (message "Wrote %s" filename))
          (setq save-buffer-coding-system coding-system-for-write)))
      (lambda (r)
        (ignore-errors (delete-file tmpfile))
        (message "Write failed: %s" r)))))

(defun dropbox-handle:file-local-copy (filename)
  (dropbox-log "[handler] file-local-copy: %s" filename)
  (unless (file-exists-p filename)
    (error "File to copy doesn't exist"))
  (save-excursion
    (let* ((recentf-list nil)
           (newname (make-temp-file
                     (file-name-nondirectory filename) nil
                     (concat "." (file-name-extension filename)))))
      (with-temp-file newname
        (set-buffer-file-coding-system 'raw-text)
        (insert-file-contents-literally filename))
      newname)))

(defun dropbox-handle:dired-insert-directory (dir switches &optional file-list wildcard _hdr)
  (dropbox-log "[handler] dired-insert-directory: %s" dir)
  (if file-list
      (cl-loop for file in file-list
               do (insert-directory (concat dir file) switches))
    (insert-directory dir switches wildcard t)))

(defun dropbox-handle:load (file &optional noerror nomessage nosuffix must-suffix)
  (let (localfile)
    (condition-case nil
        (setq localfile (file-local-copy file))
      (error (user-error "File %s not found" file)))
    (when (and localfile (file-exists-p localfile))
      (let ((signal-hook-function (unless noerror signal-hook-function))
            (inhibit-message (or inhibit-message nomessage)))
        (unwind-protect
            (load localfile noerror t nosuffix must-suffix)
          (delete-file localfile))))))

;; Rude handling

(defun dropbox-handle:file-writable-p (_) t)
(defun dropbox-handle:file-owner-preserved-p (_) t)
(defun dropbox-handle:directory-files (&rest _) nil)
(defun dropbox-handle:make-symbolic-link (&rest _) nil)
(defun dropbox-handle:add-name-to-file (&rest _) nil)
(defun dropbox-handle:dired-compress-file (&rest _) nil)
(defun dropbox-handle:find-backup-file-name (&rest _) nil)
(defun dropbox-handle:unhandled-file-name-directory (&rest _) nil)
(defun dropbox-handle:start-file-process (&rest _) nil)
(defun dropbox-handle:process-file (&rest _) nil)
(defun dropbox-handle:shell-command (&rest _) nil)
(defun dropbox-handle:executable-find (_) nil)
(defun dropbox-handle:vc-registered (&rest _) nil)
(defun dropbox-handle:set-file-modes (&rest _) nil)
(defun dropbox-handle:set-file-times (&rest _) nil)
(defun dropbox-handle:set-visited-file-modtime (&rest _) nil)
(defun dropbox-handle:verify-visited-file-modtime (&optional _buf) t)
(defun dropbox-handle:file-selinux-context (&rest _) nil)


;;; Revisions

(defvar dropbox-revisions-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "RET") #'dropbox-revision-open)
    (define-key map (kbd "d")   #'dropbox-revision-diff)
    (define-key map (kbd "R")   #'dropbox-revision-restore)
    (define-key map (kbd "w")   #'dropbox-revision-download)
    (define-key map (kbd "g")   #'dropbox-revisions-refresh)
    (define-key map (kbd "q")   #'kill-buffer-and-window)
    map)
  "Keymap for Dropbox revisions mode.")

(define-derived-mode dropbox-revisions-mode special-mode "Dropbox Revisions"
  "Major mode for listing Dropbox file revisions."
  (setq-local truncate-lines t)
  (setq-local cursor-type 'box))

(defvar-local dropbox-revisions--path nil)

(defun dropbox-revisions (path)
  "Fetch and display revisions for PATH."
  (interactive (list (dropbox-file-at-point)))
  (message "Fetching revisions for %s..." path)
  (pdd-then (dropbox--list-revisions path)
    (lambda (revisions)
      (let ((buf-name "*Dropbox Revisions*"))
        (if (cdr revisions)
            (with-current-buffer (get-buffer-create buf-name)
              (let ((inhibit-read-only t))
                (erase-buffer)
                (dropbox-revisions-mode)
                (setq dropbox-revisions--path path)
                (setq-local header-line-format
                            (concat " Revisions for: "
                                    (propertize path 'face 'font-lock-string-face)
                                    "    Keys: [RET] view [d] diff [w] save as [q] quit"))
                (insert "\n")
                (insert (propertize "REV                    Last Modified        Size      Path\n" 'face 'bold))
                (insert (propertize "---------------------  -------------------  --------  -------------\n" 'face 'bold))
                (if (not revisions)
                    (insert "(No revisions found or file is deleted)\n")
                  (dolist (rev revisions)
                    (let ((start (point))
                          (line (format "%s  %s  %8s  %s"
                                        (propertize (alist-get 'rev rev) 'face 'font-lock-comment-face)
                                        (format-time-string
                                         "%Y-%m-%d %H:%M:%S" (pdd-encode-time-string (alist-get 'client_modified rev)))
                                        (file-size-human-readable (alist-get 'size rev))
                                        (alist-get 'path_display rev))))
                      (insert (concat line (if (= (line-number-at-pos) 4) "  (current)") "\n"))
                      (put-text-property start (1- (point)) 'dropbox-revision-entry rev)
                      (put-text-property start (1- (point)) 'mouse-face 'highlight)
                      (put-text-property start (1- (point)) 'help-echo (alist-get 'id rev)))))
                (pop-to-buffer (current-buffer) '((display-buffer-reuse-window display-buffer-below-selected)))
                (goto-char (point-min))
                (forward-line 3)
                (message "")))
          (let ((buf (get-buffer buf-name)))
            (when (and buf (get-buffer-window buf))
              (with-current-buffer buf (kill-buffer-and-window))))
          (message "No revisions found for %s (or file might be deleted)." path))))
    (lambda (err)
      (message "Error fetching revisions: %s" err))))

(defun dropbox-revisions-refresh ()
  "Refresh the list of revisions."
  (interactive nil dropbox-revisions-mode)
  (unless (derived-mode-p 'dropbox-revisions-mode)
    (user-error "Not in a Dropbox Revisions buffer"))
  (dropbox-revisions dropbox-revisions--path))

(defun dropbox-revision-at-point ()
  "Return the revision entry (alist) at point."
  (or (save-excursion
        (beginning-of-line)
        (get-text-property (point) 'dropbox-revision-entry))
      (user-error "No revision found at point")))

(defun dropbox-revision-open (revision)
  "Open REVISION in buffer."
  (interactive (list (dropbox-revision-at-point)) dropbox-revisions-mode)
  (pdd-then (dropbox--download (concat "rev:" (alist-get 'rev revision)))
    (lambda (content)
      (dropbox-temp-file-buffer
       (format "[%s] %s" (alist-get 'rev revision) (alist-get 'name revision))
       content '((display-buffer-same-window))))))

(defun dropbox-revision-diff (revision)
  "Show diff between current REVISION and the previous version."
  (interactive (list (dropbox-revision-at-point)) dropbox-revisions-mode)
  (pdd-let* ((path (alist-get 'path_display revision))
             (revs (await (dropbox--list-revisions path)))
             (prev (cadr (nthcdr (cl-position-if (lambda (x)
                                                   (equal (alist-get 'rev x) (alist-get 'rev revision)))
                                                 revs)
                                 revs))))
    (if prev
        (let* ((content1 (await (dropbox--download (concat "rev:" (alist-get 'rev prev)))))
               (content2 (await (dropbox--download (concat "rev:" (alist-get 'rev revision)))))
               (display-buffer-alist '((".*" display-buffer-same-window))))
          (diff-buffers (dropbox-temp-file-buffer path content1)
                        (dropbox-temp-file-buffer path content2)))
      (message "This is already the oldest revision, no target to compare with."))))

(defun dropbox-revision-restore (revision &optional path)
  "Restore a REVISION to a remote file in PATH."
  (interactive (list (dropbox-revision-at-point)) dropbox-revisions-mode)
  (unless path
    (setq path (read-file-name "Restore to: " nil (alist-get 'path_display revision))))
  (pdd-then (dropbox--restore (alist-get 'rev revision) path)
    (lambda (_)
      (when (equal (alist-get 'path_display revision) (dropbox-normalize path))
        (dropbox-revisions-refresh))
      (message "Restored to `%s'" path))
    (lambda (r) (message "Restore failed: %s" r))))

(defun dropbox-revision-download (revision &optional path)
  "Download a Dropbox REVISION to a loacal PATH."
  (interactive (list (dropbox-revision-at-point)) dropbox-revisions-mode)
  (unless path
    (setq path (read-file-name "Download to: " "~/"
                               (file-name-nondirectory (alist-get 'path_display revision)))))
  (when (directory-name-p path)
    (setq path (expand-file-name (alist-get 'name revision) path)))
  (let ((pdd-sync t))
    (pdd-then (dropbox--download (concat "rev:" (alist-get 'rev revision)))
      (lambda (content)
        (let ((coding-system-for-write 'no-conversion))
          (write-region content nil path)))
      (lambda (r) (message "Download failed: %s" r)))))


;;; Patches

(declare-function project--find-in-directory "ext:project.el" t t)

(defun dropbox-project--find-in-directory-advice (fn dir)
  "Tell directly which DIR is the project root. FN is the origin function."
  (if (dropbox-file-p dir)
      (cons 'transient dropbox-prefix)
    (funcall fn dir)))

(advice-add #'project--find-in-directory :around #'dropbox-project--find-in-directory-advice)



;;;###autoload
(defun dropbox-find (&optional choose-dir)
  "Search files from Dropbox and open it.
It will search all files, with a prefix CHOOSE-DIR
will allow you specify a dir to search."
  (interactive "P")
  (let ((input (read-string "Search Dropbox with: ")))
    (when (< (length (replace-regexp-in-string " " "" input)) 1)
      (user-error "Maybe input is too short"))
    (let* ((dir (when choose-dir
                  (let* ((def (if (dropbox-file-p default-directory) (dropbox-normalize default-directory)))
                         (sel (read-string "Searching in directory: " def)))
                    (cond ((or (null sel) (string= sel "/") (string= sel "")) nil)
                          (t (if (string-prefix-p "/" sel) sel (concat "/" sel)))))))
           (desc (if dir (concat " in '" dir "'") ""))
           (prompt (format "Dropbox files%s (matching %s): " desc input)))
      (message "Searching%s..." desc)
      (pdd-let*
          ((files (await (dropbox--search input dir)))
           (candicates (cl-loop for file across (alist-get 'matches files)
                                for metadata = (alist-get 'metadata (alist-get 'metadata file))
                                when (string= (alist-get '\.tag metadata) "file")
                                collect (dropbox-normalize (alist-get 'path_display metadata) t))))
        (when (< (length candicates) 1)
          (user-error "Nothing found on Dropbox%s with '%s'" desc input))
        (let ((f (completing-read prompt candicates)))
          (if (dropbox-file-p f)
              (find-file f)
            (user-error "Are you taking an odd file '%s' as a dropbox file?" f)))))))

;;;###autoload
(defun dropbox-browser (&optional dir)
  "Open current directory or DIR in your browser."
  (interactive)
  (let* ((home "https://www.dropbox.com/home")
         (dir (or dir (if (dropbox-file-p default-directory) (dropbox-normalize default-directory))))
         (path (read-string "Directory open in browser: " dir)))
    (unless (string-prefix-p "/" path)
      (user-error "Maybe you input a invalid path"))
    (browse-url (concat home path))))

;;;###autoload
(defun dropbox-shared-link (path)
  "Return or create the shared link of PATH."
  (interactive (list (dropbox-file-at-point 'allow-dir)))
  (pdd-chain (dropbox--list-shared-links)
    (lambda (links)
      (if-let* ((link (cl-find-if (lambda (link)
                                    (pdd-ci-equal (alist-get 'path_lower link)
                                                  (dropbox-normalize path)))
                                  links)))
          (alist-get 'url link)
        (dropbox--create-shared-link path)))
    (lambda (url)
      (kill-new url)
      (dropbox-clear-cache (lambda (key) (eq (car-safe key) 'shared)))
      (message "Saved in kill ring: %s" (propertize url 'face 'font-lock-string-face)))
    :fail
    (lambda (r) (message "Failed to get shared link: %s" r))))

;;;###autoload
(defun dropbox-shared-link-revoke ()
  "Revoke shared link."
  (interactive)
  (let* ((pdd-sync t)
         (links (or (dropbox--list-shared-links)
                    (user-error "No shared links found")))
         (cands (mapcar (lambda (item) (cons (alist-get 'path_lower item) item)) links))
         (path (completing-read "Shared link to revoke: "
                                cands nil t nil nil
                                (ignore-errors (dropbox-normalize (dropbox-file-at-point t)))))
         (entry (alist-get path cands nil nil #'equal))
         (url (alist-get 'url entry)))
    (dropbox--revoke-shared-link url)
    (dropbox-clear-cache (lambda (key) (eq (car-safe key) 'shared)))
    (message "Revoke done: `%s'." url)))

;;;###autoload
(defun dropbox-download-link (&optional path)
  "Get the temporary link of PATH for download."
  (interactive (list (dropbox-file-at-point)))
  (pdd-then (dropbox--get-temporary-link path)
    (lambda (link)
      (kill-new link)
      (message "Saved in kill ring: %s" (propertize link 'face 'font-lock-string-face)))
    (lambda (r)
      (message "Failed to get download link: %s" r))))

;;;###autoload
(defun dropbox-clear-cache (&optional key)
  "Clear KEY from default cache storage.
If KEY is nil then clear all. KEY also can be a function."
  (interactive)
  (pdd-cacher-clear dropbox--shared-storage (if (functionp key) key t))
  (when (called-interactively-p 'any)
    (message "Caches in memory cleared.")))

;;;###autoload
(defun dropbox-clear-cache-on-disk (&optional arg)
  "Clear disk cache for current Dropbox file at point.
If no Dropbox file found at point or ARG not nil, prompt to clear all."
  (interactive "P")
  (let* ((file (ignore-errors (and (not arg) (dropbox-file-at-point))))
         (meta (and file (dropbox--get-metadata file t)))
         (cache (and meta (concat (expand-file-name (alist-get 'id meta) dropbox-disk-cache-location) ":000.cache"))))
    (if (and cache (file-exists-p cache))
        (progn (delete-file cache)
               (message "Current cache for file at point is deleted."))
      (when (y-or-n-p "There is no disk cache for item at point, clear all in cache directory?")
        (mapc #'delete-file (directory-files dropbox-disk-cache-location t ".*\\.cache"))
        (message "Disk caches all cleared.")))))

;;;###autoload
(add-to-list 'file-name-handler-alist
             `(,(concat "\\`" dropbox-prefix) . dropbox-handler))

(provide 'dropbox)

;;; dropbox.el ends here
